diff --git a/riocli/bootstrap.py b/riocli/bootstrap.py index 5e01032c..c4877018 100644 --- a/riocli/bootstrap.py +++ b/riocli/bootstrap.py @@ -40,6 +40,7 @@ from riocli.hwil import hwildevice from riocli.managedservice import managedservice from riocli.network import network +from riocli.oauth2 import oauth2 from riocli.organization import organization from riocli.package import package from riocli.parameter import parameter @@ -166,3 +167,4 @@ def update(silent: bool) -> None: cli.add_command(config_trees) cli.add_command(hwildevice) cli.add_command(cli_context) +cli.add_command(oauth2) diff --git a/riocli/oauth2/__init__.py b/riocli/oauth2/__init__.py new file mode 100644 index 00000000..a7934c5b --- /dev/null +++ b/riocli/oauth2/__init__.py @@ -0,0 +1,57 @@ +# Copyright 2025 Rapyuta Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import click +from click_help_colors import HelpColorsGroup +from riocli.oauth2.create import create_oauth2_client +from riocli.oauth2.delete import delete_oauth2_client +from riocli.oauth2.inspect import inspect_oauth2_client +from riocli.oauth2.list import list_oauth2_clients +from riocli.oauth2.update import update_oauth2_client, update_oauth2_client_uri + + +@click.group( + invoke_without_command=False, + cls=HelpColorsGroup, + help_headers_color="yellow", + help_options_color="green", +) +def oauth2() -> None: + """ + OAuth2 Admin operations. + """ + pass + + +@click.group( + "client", + invoke_without_command=False, + cls=HelpColorsGroup, + help_headers_color="yellow", + help_options_color="green", +) +def oauth2_clients() -> None: + """ + OAuth2 Admin operations. + """ + pass + + +oauth2.add_command(oauth2_clients) + +oauth2_clients.add_command(list_oauth2_clients) +oauth2_clients.add_command(create_oauth2_client) +oauth2_clients.add_command(update_oauth2_client) +oauth2_clients.add_command(update_oauth2_client_uri) +oauth2_clients.add_command(delete_oauth2_client) +oauth2_clients.add_command(inspect_oauth2_client) diff --git a/riocli/oauth2/create.py b/riocli/oauth2/create.py new file mode 100644 index 00000000..b35a9b03 --- /dev/null +++ b/riocli/oauth2/create.py @@ -0,0 +1,225 @@ +# Copyright 2025 Rapyuta Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from typing import Any + +import click +from click_help_colors import HelpColorsCommand +from munch import unmunchify +from yaspin.core import Yaspin + +from riocli.config import get_config_from_context +from riocli.constants.colors import Colors +from riocli.constants.symbols import Symbols +from riocli.oauth2.util import sanitize_parameters +from riocli.utils import inspect_with_format +from riocli.utils.spinner import with_spinner + + +@click.command( + "create", + cls=HelpColorsCommand, + help_headers_color=Colors.YELLOW, + help_options_color=Colors.GREEN, +) +@click.option( + "--access-token-strategy", + type=click.Choice(["opaque", "jwt"]), + help="The strategy used to generate access tokens. Valid options are opaque and `jwt`.", +) +@click.option( + "--allowed-cors-origin", + "allowed_cors_orogins", + multiple=True, + type=str, + help="The list of URLs allowed to make CORS requests. Requires CORS_ENABLED.", +) +@click.option( + "--audience", + multiple=True, + type=str, + help="The audience this client is allowed to request.", +) +@click.option( + "--backchannel-logout-callback", + type=str, + help="Client URL that will cause the client to log itself out when sent a Logout Token by Hydra.", +) +@click.option( + "--backchannel-logout-session-required", + is_flag=True, + default=False, + help="Boolean flag specifying whether the client requires that a sid (session ID) Claim be included in the Logout Token.", +) +@click.option( + "--client-uri", + type=str, + help="A URL string of a web page providing information about the client", +) +@click.option( + "--contact", + "contacts", + multiple=True, + type=str, + help="A list representing ways to contact people responsible for this client, typically email addresses.", +) +@click.option( + "--frontchannel-logout-callback", + type=str, + help="Client URL that will cause the client to log itself out when rendered in an iframe by Hydra.", +) +@click.option( + "--frontchannel-logout-session-required", + is_flag=True, + default=False, + help="Boolean flag specifying whether the client requires that a sid (session ID) Claim be included in the Logout Token.", +) +@click.option( + "--grant-type", + "grant_types", + multiple=True, + default=["authorization_code"], + type=str, + help="A list of allowed grant types.", +) +@click.option("--id", type=str, help="Provide the client's id.") +@click.option( + "--jwks-uri", + type=str, + help="Define the URL where the JSON Web Key Set should be fetched from when performing the private_key_jwt client authentication method.", +) +@click.option( + "--keybase", type=str, help="Keybase username for encrypting client secret." +) +@click.option( + "--logo-uri", type=str, help="A URL string that references a logo for the client" +) +@click.option( + "--metadata", + default="{}", + type=str, + help="Metadata is an arbitrary JSON String of your choosing.", +) +@click.option("--name", type=str, help="The client's name.") +@click.option( + "--owner", + type=str, + help="The owner of this client, typically email addresses or a user ID.", +) +@click.option( + "--pgp-key", + type=str, + help="Base64 encoded PGP encryption key for encrypting client secret.", +) +@click.option( + "--pgp-key-url", type=str, help="PGP encryption key URL for encrypting client secret." +) +@click.option( + "--policy-uri", + type=str, + help="A URL string that points to a human-readable privacy policy document.", +) +@click.option( + "--post-logout-callback", + "post_logout_redirect_uris", + multiple=True, + type=str, + help="List of allowed URLs to be redirected to after a logout.", +) +@click.option( + "--redirect-uri", + "redirect_uris", + multiple=True, + type=str, + help="List of allowed OAuth2 Redirect URIs.", +) +@click.option( + "--request-object-signing-alg", + default="RS256", + type=str, + help="Algorithm that must be used for signing Request Objects sent to the OP.", +) +@click.option( + "--request-uri", + "request_uris", + multiple=True, + type=str, + help="Array of request_uri values that are pre-registered by the RP for use at the OP.", +) +@click.option( + "--response-type", + "response_types", + multiple=True, + default=["code"], + type=str, + help="A list of allowed response types.", +) +@click.option( + "--scope", multiple=True, type=str, help="The scope the client is allowed to request." +) +@click.option("--secret", type=str, help="Provide the client's secret.") +@click.option( + "--sector-identifier-uri", + type=str, + help="URL using the https scheme to be used in calculating Pseudonymous Identifiers by the OP.", +) +@click.option( + "--skip-consent", + is_flag=True, + default=False, + help="Boolean flag specifying whether to skip the consent screen for this client.", +) +@click.option( + "--skip-logout-consent", + is_flag=True, + default=False, + help="Boolean flag specifying whether to skip the logout consent screen for this client.", +) +@click.option( + "--subject-type", + default="public", + type=click.Choice(["public", "pairwise"]), + help="A identifier algorithm. Valid values are public and `pairwise`.", +) +@click.option( + "--token-endpoint-auth-method", + default="client_secret_basic", + type=click.Choice( + ["client_secret_post", "client_secret_basic", "private_key_jwt", "none"] + ), + help="Define which authentication method the client may use at the Token Endpoint.", +) +@click.option( + "--tos-uri", + type=str, + help="A URL string that points to a human-readable terms of service document for the client.", +) +@click.pass_context +@with_spinner(text="Creating OAuth2 Client...") +def create_oauth2_client(ctx: click.Context, spinner: Yaspin, **params: dict[str, Any]): + params = sanitize_parameters(params) + + try: + config = get_config_from_context(ctx) + client = config.new_v2_client(with_project=False) + oauth2_client = client.create_oauth2_client(client=params) + with spinner.hidden(): + inspect_with_format(unmunchify(oauth2_client), format_type="json") + spinner.text = click.style("OAuth2 Client created successfully.", fg=Colors.GREEN) + spinner.green.ok(Symbols.SUCCESS) + except Exception as e: + spinner.text = click.style( + "Failed to create OAuth2 Client: {}".format(e), fg=Colors.RED + ) + spinner.red.fail(Symbols.ERROR) + raise SystemExit(1) diff --git a/riocli/oauth2/delete.py b/riocli/oauth2/delete.py new file mode 100644 index 00000000..b56a1391 --- /dev/null +++ b/riocli/oauth2/delete.py @@ -0,0 +1,60 @@ +# Copyright 2025 Rapyuta Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import click +from click_help_colors import HelpColorsCommand +from yaspin.core import Yaspin + +from riocli.config import get_config_from_context +from riocli.constants.colors import Colors +from riocli.constants.symbols import Symbols +from riocli.utils.spinner import with_spinner + + +@click.command( + "delete", + cls=HelpColorsCommand, + help_headers_color=Colors.YELLOW, + help_options_color=Colors.GREEN, +) +@click.argument( + "client-id", + type=str, +) +@click.option( + "--force", "-f", "--silent", "force", is_flag=True, help="Skip confirmation" +) +@click.pass_context +@with_spinner(text="Deleting OAuth2 Client...") +def delete_oauth2_client( + ctx: click.Context, client_id: str, force: bool, spinner: Yaspin +): + if not force: + with spinner.hidden(): + click.confirm( + "Deleting OAuth2 Client {}".format(client_id), + abort=True, + ) + + try: + config = get_config_from_context(ctx) + client = config.new_v2_client(with_project=False) + client.delete_oauth2_client(client_id=client_id) + spinner.text = click.style("OAuth2 Client deleted successfully.", fg=Colors.GREEN) + spinner.green.ok(Symbols.SUCCESS) + except Exception as e: + spinner.text = click.style( + "Failed to delete OAuth2 Client: {}".format(e), fg=Colors.RED + ) + spinner.red.fail(Symbols.ERROR) + raise SystemExit(1) diff --git a/riocli/oauth2/inspect.py b/riocli/oauth2/inspect.py new file mode 100644 index 00000000..b3a32941 --- /dev/null +++ b/riocli/oauth2/inspect.py @@ -0,0 +1,50 @@ +# Copyright 2025 Rapyuta Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import click +from click_help_colors import HelpColorsCommand +from munch import unmunchify + +from riocli.config import get_config_from_context +from riocli.constants.colors import Colors +from riocli.utils import inspect_with_format + + +@click.command( + "inspect", + cls=HelpColorsCommand, + help_headers_color=Colors.YELLOW, + help_options_color=Colors.GREEN, +) +@click.option( + "--format", + "-f", + "format_type", + default="yaml", + type=click.Choice(["json", "yaml"], case_sensitive=False), +) +@click.argument("client-id", type=str) +@click.pass_context +def inspect_oauth2_client( + ctx: click.Context, + client_id: str, + format_type: str, +) -> None: + try: + config = get_config_from_context(ctx) + client = config.new_v2_client(with_project=False) + oauth2_client = client.get_oauth2_client(client_id) + inspect_with_format(unmunchify(oauth2_client), format_type) + except Exception as e: + click.secho(str(e), fg=Colors.RED) + raise SystemExit(1) diff --git a/riocli/oauth2/list.py b/riocli/oauth2/list.py new file mode 100644 index 00000000..6aa8b311 --- /dev/null +++ b/riocli/oauth2/list.py @@ -0,0 +1,80 @@ +# Copyright 2025 Rapyuta Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import typing + +import click +import munch +from click_help_colors import HelpColorsCommand +from munch import unmunchify + +from riocli.config import get_config_from_context +from riocli.constants.colors import Colors +from riocli.utils import tabulate_data + + +@click.command( + "list", + cls=HelpColorsCommand, + help_headers_color=Colors.YELLOW, + help_options_color=Colors.GREEN, +) +@click.option( + "--wide", "-w", is_flag=True, default=False, help="Print more details", type=bool +) +@click.pass_context +def list_oauth2_clients( + ctx: click.Context, + wide: bool = False, +) -> None: + try: + config = get_config_from_context(ctx) + client = config.new_v2_client(with_project=False) + oauth2_clients = client.list_oauth2_clients() + _display_oauth2_client_list(oauth2_clients, wide=wide) + except Exception as e: + click.secho(str(e), fg=Colors.RED) + raise SystemExit(1) + + +def _display_oauth2_client_list( + oauth2_clients: typing.List[munch.Munch], + show_header: bool = True, + wide: bool = False, +) -> None: + headers = [] + if show_header: + headers = ["Client ID", "Client", "Grant Types", "Response Types", "Scope"] + if wide: + headers.extend(["Redirect URIs", "Logout Redirect URIs"]) + + data = [] + for client in oauth2_clients: + row = [ + client.client_id, + client.client_name, + unmunchify(client.grant_types), + unmunchify(client.response_types), + client.scope, + ] + if wide: + row.extend( + [ + unmunchify(client.get("redirect_uris", None)), + unmunchify(client.get("post_logout_redirect_uris", None)), + ] + ) + + data.append(row) + + tabulate_data(data, headers) diff --git a/riocli/oauth2/update.py b/riocli/oauth2/update.py new file mode 100644 index 00000000..2dd0aae2 --- /dev/null +++ b/riocli/oauth2/update.py @@ -0,0 +1,287 @@ +# Copyright 2025 Rapyuta Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from typing import Any, Optional, Tuple + +import click +from click_help_colors import HelpColorsCommand +from munch import unmunchify +from yaspin.core import Yaspin + +from riocli.config import get_config_from_context +from riocli.constants.colors import Colors +from riocli.constants.symbols import Symbols +from riocli.oauth2.util import sanitize_parameters +from riocli.utils import inspect_with_format +from riocli.utils.spinner import with_spinner + + +@click.command( + "update", + cls=HelpColorsCommand, + help_headers_color=Colors.YELLOW, + help_options_color=Colors.GREEN, +) +@click.option( + "--access-token-strategy", + type=click.Choice(["opaque", "jwt"]), + help="The strategy used to generate access tokens. Valid options are opaque and `jwt`.", +) +@click.option( + "--allowed-cors-origin", + "allowed_cors_orogins", + multiple=True, + type=str, + help="The list of URLs allowed to make CORS requests. Requires CORS_ENABLED.", +) +@click.option( + "--audience", + multiple=True, + type=str, + help="The audience this client is allowed to request.", +) +@click.option( + "--backchannel-logout-callback", + type=str, + help="Client URL that will cause the client to log itself out when sent a Logout Token by Hydra.", +) +@click.option( + "--backchannel-logout-session-required", + is_flag=True, + default=False, + help="Boolean flag specifying whether the client requires that a sid (session ID) Claim be included in the Logout Token.", +) +@click.option( + "--client-uri", + type=str, + help="A URL string of a web page providing information about the client", +) +@click.option( + "--contact", + "contacts", + multiple=True, + type=str, + help="A list representing ways to contact people responsible for this client, typically email addresses.", +) +@click.option( + "--frontchannel-logout-callback", + type=str, + help="Client URL that will cause the client to log itself out when rendered in an iframe by Hydra.", +) +@click.option( + "--frontchannel-logout-session-required", + is_flag=True, + default=False, + help="Boolean flag specifying whether the client requires that a sid (session ID) Claim be included in the Logout Token.", +) +@click.option( + "--grant-type", + "grant_types", + multiple=True, + default=["authorization_code"], + type=str, + help="A list of allowed grant types.", +) +@click.option( + "--jwks-uri", + type=str, + help="Define the URL where the JSON Web Key Set should be fetched from when performing the private_key_jwt client authentication method.", +) +@click.option( + "--keybase", type=str, help="Keybase username for encrypting client secret." +) +@click.option( + "--logo-uri", type=str, help="A URL string that references a logo for the client" +) +@click.option( + "--metadata", + default="{}", + type=str, + help="Metadata is an arbitrary JSON String of your choosing.", +) +@click.option("--name", type=str, help="The client's name.") +@click.option( + "--owner", + type=str, + help="The owner of this client, typically email addresses or a user ID.", +) +@click.option( + "--pgp-key", + type=str, + help="Base64 encoded PGP encryption key for encrypting client secret.", +) +@click.option( + "--pgp-key-url", type=str, help="PGP encryption key URL for encrypting client secret." +) +@click.option( + "--policy-uri", + type=str, + help="A URL string that points to a human-readable privacy policy document.", +) +@click.option( + "--post-logout-callback", + "post_logout_redirect_uris", + multiple=True, + type=str, + help="List of allowed URLs to be redirected to after a logout.", +) +@click.option( + "--redirect-uri", + "redirect_uris", + multiple=True, + type=str, + help="List of allowed OAuth2 Redirect URIs.", +) +@click.option( + "--request-object-signing-alg", + default="RS256", + type=str, + help="Algorithm that must be used for signing Request Objects sent to the OP.", +) +@click.option( + "--request-uri", + "request_uris", + multiple=True, + type=str, + help="Array of request_uri values that are pre-registered by the RP for use at the OP.", +) +@click.option( + "--response-type", + "response_types", + multiple=True, + default=["code"], + type=str, + help="A list of allowed response types.", +) +@click.option( + "--scope", multiple=True, type=str, help="The scope the client is allowed to request." +) +@click.option("--secret", type=str, help="Provide the client's secret.") +@click.option( + "--sector-identifier-uri", + type=str, + help="URL using the https scheme to be used in calculating Pseudonymous Identifiers by the OP.", +) +@click.option( + "--skip-consent", + is_flag=True, + default=False, + help="Boolean flag specifying whether to skip the consent screen for this client.", +) +@click.option( + "--skip-logout-consent", + is_flag=True, + default=False, + help="Boolean flag specifying whether to skip the logout consent screen for this client.", +) +@click.option( + "--subject-type", + default="public", + type=click.Choice(["public", "pairwise"]), + help="A identifier algorithm. Valid values are public and `pairwise`.", +) +@click.option( + "--token-endpoint-auth-method", + default="client_secret_basic", + type=click.Choice( + ["client_secret_post", "client_secret_basic", "private_key_jwt", "none"] + ), + help="Define which authentication method the client may use at the Token Endpoint.", +) +@click.option( + "--tos-uri", + type=str, + help="A URL string that points to a human-readable terms of service document for the client.", +) +@click.argument( + "client-id", + type=str, +) +@click.pass_context +@with_spinner(text="Updating OAuth2 Client...") +def update_oauth2_client( + ctx: click.Context, client_id: str, spinner: Yaspin, **params: dict[str, Any] +): + params = sanitize_parameters(params) + + try: + config = get_config_from_context(ctx) + client = config.new_v2_client(with_project=False) + oauth2_client = client.update_oauth2_client(client_id=client_id, client=params) + with spinner.hidden(): + inspect_with_format(unmunchify(oauth2_client), format_type="json") + spinner.text = click.style("OAuth2 Client updated successfully.", fg=Colors.GREEN) + spinner.green.ok(Symbols.SUCCESS) + except Exception as e: + spinner.text = click.style( + "Failed to update OAuth2 Client: {}".format(e), fg=Colors.RED + ) + spinner.red.fail(Symbols.ERROR) + raise SystemExit(1) + + +@click.command( + "update-uri", + cls=HelpColorsCommand, + help_headers_color=Colors.YELLOW, + help_options_color=Colors.GREEN, +) +@click.option( + "--post-logout-callback", + "post_logout_redirect_uris", + multiple=True, + type=str, + help="List of allowed URLs to be redirected to after a logout.", +) +@click.option( + "--redirect-uri", + "redirect_uris", + multiple=True, + type=str, + help="List of allowed OAuth2 Redirect URIs.", +) +@click.argument( + "client-id", + type=str, +) +@click.pass_context +@with_spinner(text="Updating OAuth2 Client...") +def update_oauth2_client_uri( + ctx: click.Context, + client_id: str, + post_logout_redirect_uris: Optional[Tuple[str]], + redirect_uris: Optional[Tuple[str]], + spinner: Yaspin, +): + payload = { + "redirectURIs": redirect_uris, + "postLogoutRedirectURIs": post_logout_redirect_uris, + } + + try: + config = get_config_from_context(ctx) + client = config.new_v2_client(with_project=False) + oauth2_client = client.update_oauth2_client_uris( + client_id=client_id, + payload=payload, + ) + with spinner.hidden(): + inspect_with_format(unmunchify(oauth2_client), format_type="json") + spinner.text = click.style("OAuth2 Client updated successfully.", fg=Colors.GREEN) + spinner.green.ok(Symbols.SUCCESS) + except Exception as e: + spinner.text = click.style( + "Failed to update OAuth2 Client: {}".format(e), fg=Colors.RED + ) + spinner.red.fail(Symbols.ERROR) + raise SystemExit(1) diff --git a/riocli/oauth2/util.py b/riocli/oauth2/util.py new file mode 100644 index 00000000..9f28fe19 --- /dev/null +++ b/riocli/oauth2/util.py @@ -0,0 +1,35 @@ +from typing import Any + +import json + + +def sanitize_parameters(params: dict[str, Any]) -> dict[str, Any]: + scope = params.pop("scope") + if scope is not None: + params["scope"] = " ".join(scope) + + grant_types = params.pop("grant_types") + if grant_types is not None: + updated = [] + for i in grant_types: + updated.extend(i.split(",")) + + params["grant_types"] = updated + + response_types = params.pop("response_types") + if response_types is not None: + updated = [] + for i in response_types: + updated.extend(i.split(",")) + + params["response_types"] = updated + + name = params.pop("name") + if name is not None: + params["client_name"] = name + + metadata = params.pop("metadata") + if metadata is not None: + params["metadata"] = json.loads(metadata) + + return params diff --git a/riocli/v2client/client.py b/riocli/v2client/client.py index d0c7160f..c82a4bc2 100644 --- a/riocli/v2client/client.py +++ b/riocli/v2client/client.py @@ -17,7 +17,7 @@ import os import time from hashlib import md5 -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Sequence import magic from munch import Munch, munchify @@ -1330,3 +1330,103 @@ def get_device_daemons(self, device_guid: str) -> Munch: raise Exception("device daemons: {}".format(err_msg)) return munchify(data) + + # OAuth2 Clients + def list_oauth2_clients(self, query: Optional[dict] = None) -> Munch: + """ + List all OAuth2 Clients in the organization. + """ + url = "{}/v2/oauth2/clients/".format(self._host) + headers = self._get_auth_header(with_project=False) + params = query or dict() + + client = RestClient(url).method(HttpMethod.GET).headers(headers) + return self._walk_pages(client, params=params) + + def get_oauth2_client(self, client_id: str) -> Munch: + """ + Get an OAuth2 Client by its ID + """ + url = "{}/v2/oauth2/clients/{}/".format(self._host, client_id) + headers = self._get_auth_header(with_project=False) + response = RestClient(url).method(HttpMethod.GET).headers(headers).execute() + + handle_server_errors(response) + + data = json.loads(response.text) + if not response.ok: + err_msg = data.get("error") + raise Exception("oauth2 client: {}".format(err_msg)) + + return munchify(data) + + def create_oauth2_client(self, client: dict[str, Any]) -> Munch: + """ + Create a new OAuth2 Client. + """ + url = "{}/v2/oauth2/clients/".format(self._host) + headers = self._get_auth_header(with_project=False) + response = ( + RestClient(url).method(HttpMethod.POST).headers(headers).execute(payload=client) + ) + + handle_server_errors(response) + + data = json.loads(response.text) + if not response.ok: + err_msg = data.get("error") + raise Exception("oauth2 client: {}".format(err_msg)) + + return munchify(data) + + def update_oauth2_client(self, client_id: str, client: dict[str, Any]) -> Munch: + """ + Create a new OAuth2 Client. + """ + url = "{}/v2/oauth2/clients/{}/".format(self._host, client_id) + headers = self._get_auth_header(with_project=False) + response = ( + RestClient(url).method(HttpMethod.PUT).headers(headers).execute(payload=client) + ) + + handle_server_errors(response) + + data = json.loads(response.text) + if not response.ok: + err_msg = data.get("error") + raise Exception("oauth2 client: {}".format(err_msg)) + + return munchify(data) + + def update_oauth2_client_uris(self, client_id: str, payload: dict[str, Optional[Sequence[str]]]) -> Munch: + url = "{}/v2/oauth2/clients/{}/uris/".format(self._host, client_id) + headers = self._get_auth_header(with_project=False) + response = ( + RestClient(url).method(HttpMethod.PUT).headers(headers).execute(payload=payload) + ) + + handle_server_errors(response) + + data = json.loads(response.text) + if not response.ok: + err_msg = data.get("error") + raise Exception("oauth2 client: {}".format(err_msg)) + + return munchify(data) + + def delete_oauth2_client(self, client_id: str) -> Munch: + """ + Delete an OAuth2 client by its id. + """ + url = "{}/v2/oauth2/clients/{}/".format(self._host, client_id) + headers = self._get_auth_header(with_project=False) + response = RestClient(url).method(HttpMethod.DELETE).headers(headers).execute() + + handle_server_errors(response) + + data = json.loads(response.text) + if not response.ok: + err_msg = data.get("error") + raise Exception("oauth2 client: {}".format(err_msg)) + + return munchify(data)