diff --git a/actions.yaml b/actions.yaml index ebccccbf..850263fe 100644 --- a/actions.yaml +++ b/actions.yaml @@ -1,2 +1,88 @@ run-migration: description: Run a command to create SQL schemas and apply migration plans. +create-oauth-client: + description: Register an oauth client + params: + audience: + description: A list with the allowed audience for the client + type: array + grant-types: + description: A list with the allowed grant types for the client + type: array + default: ["authorization_code"] + redirect-uris: + description: A list with the client's redirect_uri + type: array + response-types: + description: A list with the allowed response types for the client + type: array + default: ["code"] + scope: + description: A list with the allowed scopes for the client + type: array + default: ["openid", "profile", "email", "phone"] + client-secret: + description: The client's secret, if not provided one will be autogenerated. + type: string + token-endpoint-auth-method: + description: The authentication method the client may use at the token endpoint. + type: string +get-oauth-client-info: + description: Get an oauth client's information + params: + client-id: + description: The client_id + type: string + required: ["client-id"] +update-oauth-client: + description: Update an oauth client + params: + client-id: + description: The client_id + type: string + audience: + description: A list with the allowed audience for the client + type: array + grant-types: + description: A list with the allowed grant types for the client + type: array + redirect-uris: + description: A list with the client's redirect_uri + type: array + response-types: + description: A list with the allowed response types for the client + type: array + scope: + description: A list with the allowed scopes for the client + type: array + client-secret: + description: The client's secret, if not provided one will be autogenerated. + type: string + token-endpoint-auth-method: + description: The authentication method the client may use at the token endpoint. + type: string + required: ["client-id"] +delete-oauth-client: + description: Delete an oauth client + params: + client-id: + description: The client_id + type: string + required: ["client-id"] +list-oauth-clients: + description: List all oauth clients +revoke-oauth-client-access-tokens: + description: Delete an oauth client's access tokens + params: + client-id: + description: The client_id + type: string + required: ["client-id"] +rotate-key: + description: Rotate the jwk used for signing tokens + params: + alg: + description: The algorithm that should be used + type: string + default: RS256 + enumerate: [RS256, RS512, ES256, ES512, EdDSA] diff --git a/src/charm.py b/src/charm.py index 06670124..d467f640 100755 --- a/src/charm.py +++ b/src/charm.py @@ -49,7 +49,7 @@ Relation, WaitingStatus, ) -from ops.pebble import ChangeError, ExecError, Layer +from ops.pebble import ChangeError, Error, ExecError, Layer from hydra_cli import HydraCLI @@ -62,6 +62,11 @@ PEER = "hydra" +def remove_none_values(dic: Dict) -> Dict: + """Remove all entries in a dict with `None` values.""" + return {k: v for k, v in dic.items() if v is not None} + + class HydraCharm(CharmBase): """Charmed Ory Hydra.""" @@ -126,6 +131,27 @@ def __init__(self, *args: Any) -> None: self.framework.observe(self.oauth.on.client_changed, self._on_client_changed) self.framework.observe(self.oauth.on.client_deleted, self._on_client_deleted) + self.framework.observe( + self.on.create_oauth_client_action, self._on_create_oauth_client_action + ) + self.framework.observe( + self.on.get_oauth_client_info_action, self._on_get_oauth_client_info_action + ) + self.framework.observe( + self.on.update_oauth_client_action, self._on_update_oauth_client_action + ) + self.framework.observe( + self.on.delete_oauth_client_action, self._on_delete_oauth_client_action + ) + self.framework.observe( + self.on.list_oauth_clients_action, self._on_list_oauth_clients_action + ) + self.framework.observe( + self.on.revoke_oauth_client_access_tokens_action, + self._on_revoke_oauth_client_access_tokens_action, + ) + self.framework.observe(self.on.rotate_key_action, self._on_rotate_key_action) + @property def _hydra_layer(self) -> Layer: """Returns a pre-configured Pebble layer.""" @@ -417,10 +443,14 @@ def _on_client_created(self, event: ClientCreatedEvent) -> None: event.defer() return - client_config = event.to_client_config() try: client = self._hydra_cli.create_client( - client_config, metadata={"relation_id": {event.relation_id}} + audience=event.audience, + grant_type=event.grant_types, + redirect_uri=event.redirect_uri.split(" "), + scope=event.scope.split(" "), + token_endpoint_auth_method=event.token_endpoint_auth_method, + metadata={"relation_id": {event.relation_id}}, ) except ExecError as err: logger.error(f"Exited with code: {err.exit_code}. Stderr: {err.stderr}") @@ -442,10 +472,15 @@ def _on_client_changed(self, event: ClientChangedEvent) -> None: event.defer() return - client_config = event.to_client_config() try: self._hydra_cli.update_client( - client_config, metadata={"relation_id": {event.relation_id}} + event.client_id, + audience=event.audience, + grant_type=event.grant_types, + redirect_uri=event.redirect_uri.split(" "), + scope=event.scope.split(" "), + token_endpoint_auth_method=event.token_endpoint_auth_method, + metadata={"relation_id": {event.relation_id}}, ) except ExecError as err: logger.error(f"Exited with code: {err.exit_code}. Stderr: {err.stderr}") @@ -482,6 +517,227 @@ def _on_client_deleted(self, event: ClientDeletedEvent) -> None: self._pop_oauth_relation_peer_data(event.relation_id) + def _on_create_oauth_client_action(self, event: ActionEvent) -> None: + if not self._hydra_service_is_running: + event.fail("Service is not ready. Please re-run the action when the charm is active") + return + + event.log("Creating client") + + cmd_kwargs = remove_none_values( + { + "audience": event.params.get("audience"), + "grant_type": event.params.get("grant-types"), + "redirect_uri": event.params.get("redirect-uris"), + "response_type": event.params.get("response-types"), + "scope": event.params.get("scope"), + "client_secret": event.params.get("client-secret"), + "token_endpoint_auth_method": event.params.get("token-endpoint-auth-method"), + } + ) + + try: + client = self._hydra_cli.create_client(**cmd_kwargs) + except Error as e: + event.fail(f"Something went wrong when trying to run the command: {e}") + return + + event.log("Successfully created client") + event.set_results( + { + "client-id": client.get("client_id"), + "client-secret": client.get("client_secret"), + "audience": client.get("audience"), + "grant-types": ", ".join(client.get("grant_types", [])), + "redirect-uris": ", ".join(client.get("redirect_uris", [])), + "response-types": ", ".join(client.get("response_types", [])), + "scope": client.get("scope"), + "token-endpoint-auth-method": client.get("token_endpoint_auth_method"), + } + ) + + def _on_get_oauth_client_info_action(self, event: ActionEvent) -> None: + if not self._hydra_service_is_running: + event.fail("Service is not ready. Please re-run the action when the charm is active") + return + + client_id = event.params["client-id"] + event.log(f"Getting client: {client_id}") + + try: + client = self._hydra_cli.get_client(client_id) + except ExecError as err: + if err.stderr and "Unable to locate the resource" in err.stderr: + event.fail(f"No such client: {client_id}") + return + event.fail(f"Exited with code: {err.exit_code}. Stderr: {err.stderr}") + return + except Error as e: + event.fail(f"Something went wrong when trying to run the command: {e}") + return + + event.log(f"Successfully fetched client: {client_id}") + # We dump everything in the result, but we have to first convert it to the + # format the juju action expects + event.set_results( + { + k.replace("_", "-"): ", ".join(v) if isinstance(v, list) else v + for k, v in client.items() + } + ) + + def _on_update_oauth_client_action(self, event: ActionEvent) -> None: + if not self._hydra_service_is_running: + event.fail("Service is not ready. Please re-run the action when the charm is active") + return + + client_id = event.params["client-id"] + try: + client = self._hydra_cli.get_client(client_id) + except ExecError as err: + if err.stderr and "Unable to locate the resource" in err.stderr: + event.fail(f"No such client: {client_id}") + return + event.fail(f"Exited with code: {err.exit_code}. Stderr: {err.stderr}") + return + except Error as e: + logger.error(f"Something went wrong when trying to run the command: {e}") + return + + if self._is_oauth_relation_client(client): + event.fail( + f"Cannot update client `{client_id}`, it is managed from an oauth relation." + ) + return + + cmd_kwargs = remove_none_values( + { + "audience": event.params.get("audience") or client.get("audience"), + "grant_type": event.params.get("grant-types") or client.get("grant_types"), + "redirect_uri": event.params.get("redirect-uris") or client.get("redirect_uris"), + "response_type": event.params.get("response-types") + or client.get("response_types"), + "scope": event.params.get("scope") or client["scope"].split(" "), + "client_secret": event.params.get("client-secret") or client.get("client_secret"), + "token_endpoint_auth_method": event.params.get("token-endpoint-auth-method") + or client.get("token_endpoint_auth_method"), + } + ) + event.log(f"Updating client: {client_id}") + try: + client = self._hydra_cli.update_client(client_id, **cmd_kwargs) + except Error as e: + event.fail(f"Something went wrong when trying to run the command: {e}") + return + + event.log(f"Successfully updated client: {client_id}") + event.set_results( + { + "client-id": client.get("client_id"), + "client-secret": client.get("client_secret"), + "audience": client.get("audience"), + "grant-types": ", ".join(client.get("grant_types", [])), + "redirect-uris": ", ".join(client.get("redirect_uris", [])), + "response-types": ", ".join(client.get("response_types", [])), + "scope": client.get("scope"), + "token-endpoint-auth-method": client.get("token_endpoint_auth_method"), + } + ) + + def _on_delete_oauth_client_action(self, event: ActionEvent) -> None: + if not self._hydra_service_is_running: + event.fail("Service is not ready. Please re-run the action when the charm is active") + return + + client_id = event.params["client-id"] + try: + client = self._hydra_cli.get_client(client_id) + except ExecError as err: + if err.stderr and "Unable to locate the resource" in err.stderr: + event.fail(f"No such client: {client_id}") + return + event.fail(f"Exited with code: {err.exit_code}. Stderr: {err.stderr}") + return + except Error as e: + logger.error(f"Something went wrong when trying to run the command: {e}") + return + + if self._is_oauth_relation_client(client): + event.fail( + f"Cannot delete client `{client_id}`, it is managed from an oauth relation. " + "To delete it, remove the relation." + ) + return + + event.log(f"Deleting client: {client_id}") + try: + self._hydra_cli.delete_client(client_id) + except Error as e: + event.fail(f"Something went wrong when trying to run the command: {e}") + return + + event.log(f"Successfully deleted client: {client_id}") + event.set_results({"client-id": client_id}) + + def _on_list_oauth_clients_action(self, event: ActionEvent) -> None: + if not self._hydra_service_is_running: + event.fail("Service is not ready. Please re-run the action when the charm is active") + return + + event.log("Fetching clients") + try: + clients = self._hydra_cli.list_clients() + except Error as e: + event.fail(f"Something went wrong when trying to run the command: {e}") + return + + event.log("Successfully listed clients") + event.set_results({str(i): c["client_id"] for i, c in enumerate(clients["items"])}) + + def _on_revoke_oauth_client_access_tokens_action(self, event: ActionEvent) -> None: + if not self._hydra_service_is_running: + event.fail("Service is not ready. Please re-run the action when the charm is active") + return + + client_id = event.params["client-id"] + event.log(f"Deleting all access tokens for client: {client_id}") + try: + client = self._hydra_cli.delete_client_access_tokens(client_id) + except ExecError as err: + if err.stderr and "Unable to locate the resource" in err.stderr: + event.fail(f"No such client: {client_id}") + return + event.fail(f"Exited with code: {err.exit_code}. Stderr: {err.stderr}") + return + except Error as e: + event.fail(f"Something went wrong when trying to run the command: {e}") + return + + event.log(f"Successfully deleted all access tokens for client: {client_id}") + event.set_results({"client-id": client}) + + def _on_rotate_key_action(self, event: ActionEvent) -> None: + if not self._hydra_service_is_running: + event.fail("Service is not ready. Please re-run the action when the charm is active") + return + + event.log("Rotating keys") + try: + jwk = self._hydra_cli.create_jwk(alg=event.params["alg"]) + except ExecError as err: + event.fail(f"Exited with code: {err.exit_code}. Stderr: {err.stderr}") + return + except Error as e: + event.fail(f"Something went wrong when trying to run the command: {e}") + return + + event.log("Successfully created new key") + event.set_results({"new-key-id": jwk["keys"][0]["kid"]}) + + def _is_oauth_relation_client(self, client: Dict) -> bool: + """Check whether a client is managed from an oauth relation.""" + return "relation_id" in client.get("metadata", {}) + def _update_endpoint_info(self) -> None: if not self.admin_ingress.url or not self.public_ingress.url: return diff --git a/src/hydra_cli.py b/src/hydra_cli.py index af5c00da..1eb0e2f4 100644 --- a/src/hydra_cli.py +++ b/src/hydra_cli.py @@ -8,10 +8,10 @@ import logging from typing import Dict, List, Optional, Tuple, Union -from charms.hydra.v0.oauth import ClientConfig from ops.model import Container logger = logging.getLogger(__name__) +SUPPORTED_SCOPES = ["openid", "profile", "email", "phone"] class HydraCLI: @@ -21,27 +21,48 @@ def __init__(self, hydra_admin_url: str, container: Container): self.hydra_admin_url = hydra_admin_url self.container = container - def _client_config_to_cmd( - self, client_config: ClientConfig, metadata: Optional[Dict] = None + def _dump_list(self, data: Optional[List]) -> str: + if not data: + return "" + return (",").join(data) + + def _dump_dict(self, data: Optional[Dict]) -> str: + if not data: + return "" + return json.dumps(data, separators=(",", ":")) + + def _build_client_cmd_flags( + self, + audience: Optional[List[str]] = None, + grant_type: Optional[List[str]] = None, + redirect_uri: Optional[List[str]] = None, + response_type: Optional[List[str]] = None, + scope: List[str] = SUPPORTED_SCOPES, + client_secret: Optional[str] = None, + token_endpoint_auth_method: Optional[str] = None, + metadata: Optional[Dict] = None, ) -> List[str]: """Convert a ClientConfig object to a list of parameters.""" - flags = [ - "--grant-type", - ",".join(client_config.grant_types or ["authorization_code", "refresh_token"]), - "--response-type", - "code", - ] - - if client_config.scope: - for scope in client_config.scope.split(" "): + flag_mapping = { + "--audience": self._dump_list(audience), + "--grant-type": self._dump_list(grant_type), + "--redirect-uri": self._dump_list(redirect_uri), + "--response-type": self._dump_list(response_type), + "--secret": client_secret, + "--token-endpoint-auth-method": token_endpoint_auth_method, + "--metadata": self._dump_dict(metadata), + } + flags = [] + + for k, v in flag_mapping.items(): + if v: + flags.append(k) + flags.append(v) + + if scope: + for s in scope: flags.append("--scope") - flags.append(scope) - if client_config.redirect_uri: - flags.append("--redirect-uri") - flags.append(client_config.redirect_uri) - if metadata: - flags.append("--metadata") - flags.append(json.dumps(metadata)) + flags.append(s) return flags def _client_cmd_prefix(self, action: str) -> List[str]: @@ -55,10 +76,27 @@ def _client_cmd_prefix(self, action: str) -> List[str]: "json", ] - def create_client(self, client_config: ClientConfig, metadata: Optional[Dict] = None) -> Dict: + def create_client( + self, + audience: Optional[List[str]] = None, + grant_type: Optional[List[str]] = None, + redirect_uri: Optional[List[str]] = None, + response_type: Optional[List[str]] = ["code"], + scope: List[str] = SUPPORTED_SCOPES, + client_secret: Optional[str] = None, + token_endpoint_auth_method: Optional[str] = None, + metadata: Optional[Dict] = None, + ) -> Dict: """Create an oauth2 client.""" - cmd = self._client_cmd_prefix("create") + self._client_config_to_cmd( - client_config, metadata + cmd = self._client_cmd_prefix("create") + self._build_client_cmd_flags( + audience=audience, + grant_type=grant_type, + redirect_uri=redirect_uri, + response_type=response_type, + scope=scope, + client_secret=client_secret, + token_endpoint_auth_method=token_endpoint_auth_method, + metadata=metadata, ) stdout, _ = self._run_cmd(cmd) @@ -66,18 +104,45 @@ def create_client(self, client_config: ClientConfig, metadata: Optional[Dict] = logger.info(f"Successfully created client: {json_stdout.get('client_id')}") return json_stdout - def update_client(self, client_config: ClientConfig, metadata: Optional[Dict] = None) -> Dict: + def get_client(self, client_id: str) -> Dict: + """Get an oauth2 client.""" + cmd = self._client_cmd_prefix("get") + cmd.append(client_id) + + stdout, _ = self._run_cmd(cmd) + logger.info(f"Successfully fetched client: {client_id}") + return json.loads(stdout) + + def update_client( + self, + client_id: str, + audience: Optional[List[str]] = None, + grant_type: Optional[List[str]] = None, + redirect_uri: Optional[List[str]] = None, + response_type: Optional[List[str]] = ["code"], + scope: List[str] = SUPPORTED_SCOPES, + client_secret: Optional[str] = None, + token_endpoint_auth_method: Optional[str] = None, + metadata: Optional[Dict] = None, + ) -> Dict: """Update an oauth2 client.""" - cmd = self._client_cmd_prefix("update") + self._client_config_to_cmd( - client_config, metadata + cmd = self._client_cmd_prefix("update") + self._build_client_cmd_flags( + audience=audience, + grant_type=grant_type, + redirect_uri=redirect_uri, + response_type=response_type, + scope=scope, + client_secret=client_secret, + token_endpoint_auth_method=token_endpoint_auth_method, + metadata=metadata, ) - cmd.append(client_config.client_id) + cmd.append(client_id) stdout, _ = self._run_cmd(cmd) - logger.info(f"Successfully updated client: {client_config.client_id}") + logger.info(f"Successfully updated client: {client_id}") return json.loads(stdout) - def delete_client(self, client_id: str) -> Dict: + def delete_client(self, client_id: str) -> str: """Delete an oauth2 client.""" cmd = self._client_cmd_prefix("delete") cmd.append(client_id) @@ -86,6 +151,59 @@ def delete_client(self, client_id: str) -> Dict: logger.info(f"Successfully deleted client: {stdout}") return json.loads(stdout) + def list_clients(self) -> Dict: + """Delete one or more oauth2 client.""" + cmd = [ + "hydra", + "list", + "clients", + "--endpoint", + self.hydra_admin_url, + "--format", + "json", + ] + + stdout, _ = self._run_cmd(cmd) + logger.info("Successfully listed clients") + return json.loads(stdout) + + def delete_client_access_tokens(self, client_id: str) -> str: + """Delete one or more oauth2 client.""" + cmd = [ + "hydra", + "delete", + "access-tokens", + "--endpoint", + self.hydra_admin_url, + "--format", + "json", + client_id, + ] + + stdout, _ = self._run_cmd(cmd) + logger.info(f"Successfully deleted all the access tokens for client: {stdout}") + return json.loads(stdout) + + def create_jwk(self, set_id: str = "hydra.openid.id-token", alg: str = "RS256") -> Dict: + """Add a new key to a jwks.""" + cmd = [ + "hydra", + "create", + "jwk", + "--endpoint", + self.hydra_admin_url, + "--format", + "json", + "--alg", + alg, + set_id, + ] + + stdout, _ = self._run_cmd(cmd) + json_stdout = json.loads(stdout) + logger.info(f"Successfully created jwk: {json_stdout['keys'][0]['kid']}") + return json_stdout + def _run_cmd( self, cmd: List[str], timeout: float = 20 ) -> Tuple[Union[str, bytes], Union[str, bytes]]: diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 1ba62908..7d2601e7 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -17,6 +17,8 @@ TRAEFIK = "traefik-k8s" TRAEFIK_ADMIN_APP = "traefik-admin" TRAEFIK_PUBLIC_APP = "traefik-public" +CLIENT_SECRET = "secret" +CLIENT_REDIRECT_URIS = ["https://example.com"] async def get_unit_address(ops_test: OpsTest, app_name: str, unit_num: int) -> str: @@ -102,3 +104,182 @@ async def test_has_admin_ingress(ops_test: OpsTest) -> None: resp = requests.get(f"http://{admin_address}/{ops_test.model.name}-{APP_NAME}/admin/clients") assert resp.status_code == 200 + + +@pytest.mark.abort_on_fail +async def test_create_client_action(ops_test: OpsTest) -> None: + action = ( + await ops_test.model.applications[APP_NAME] + .units[0] + .run_action( + "create-oauth-client", + **{ + "redirect-uris": CLIENT_REDIRECT_URIS, + "client-secret": CLIENT_SECRET, + }, + ) + ) + res = (await action.wait()).results + + assert res["client-secret"] == CLIENT_SECRET + assert res["redirect-uris"] == "https://example.com" + + +async def test_list_client(ops_test: OpsTest) -> None: + action = ( + await ops_test.model.applications[APP_NAME] + .units[0] + .run_action( + "list-oauth-clients", + ) + ) + res = (await action.wait()).results + + assert len(res) > 0 + + +async def test_get_client(ops_test: OpsTest) -> None: + action = ( + await ops_test.model.applications[APP_NAME] + .units[0] + .run_action( + "list-oauth-clients", + ) + ) + res = (await action.wait()).results + client_id = res["0"] + + action = ( + await ops_test.model.applications[APP_NAME] + .units[0] + .run_action( + "get-oauth-client-info", + **{ + "client-id": client_id, + }, + ) + ) + res = (await action.wait()).results + + assert res["redirect-uris"] == " ,".join(CLIENT_REDIRECT_URIS) + + +async def test_update_client(ops_test: OpsTest) -> None: + action = ( + await ops_test.model.applications[APP_NAME] + .units[0] + .run_action( + "list-oauth-clients", + ) + ) + res = (await action.wait()).results + client_id = res["0"] + + redirect_uris = ["https://other.app/oauth/callback"] + action = ( + await ops_test.model.applications[APP_NAME] + .units[0] + .run_action( + "update-oauth-client", + **{ + "client-id": client_id, + "redirect-uris": redirect_uris, + }, + ) + ) + res = (await action.wait()).results + + assert res["redirect-uris"] == " ,".join(redirect_uris) + + +async def test_revoke_access_tokens_client(ops_test: OpsTest) -> None: + action = ( + await ops_test.model.applications[APP_NAME] + .units[0] + .run_action( + "list-oauth-clients", + ) + ) + res = (await action.wait()).results + client_id = res["0"] + + action = ( + await ops_test.model.applications[APP_NAME] + .units[0] + .run_action( + "revoke-oauth-client-access-tokens", + **{ + "client-id": client_id, + }, + ) + ) + res = (await action.wait()).results + + # TODO: Test that tokens are actually deleted? + assert res["client-id"] == client_id + + +async def test_delete_client(ops_test: OpsTest) -> None: + action = ( + await ops_test.model.applications[APP_NAME] + .units[0] + .run_action( + "list-oauth-clients", + ) + ) + res = (await action.wait()).results + client_id = res["0"] + + action = ( + await ops_test.model.applications[APP_NAME] + .units[0] + .run_action( + "delete-oauth-client", + **{ + "client-id": client_id, + }, + ) + ) + res = (await action.wait()).results + + assert res["client-id"] == client_id + + action = ( + await ops_test.model.applications[APP_NAME] + .units[0] + .run_action( + "get-oauth-client-info", + **{ + "client-id": client_id, + }, + ) + ) + res = await action.wait() + + assert res.status == "failed" + assert res.data["message"] == f"No such client: {client_id}" + + +async def test_rotate_keys(ops_test: OpsTest) -> None: + public_address = await get_unit_address(ops_test, TRAEFIK_PUBLIC_APP, 0) + + jwks = requests.get( + f"http://{public_address}/{ops_test.model.name}-{APP_NAME}/.well-known/jwks.json" + ) + + action = ( + await ops_test.model.applications[APP_NAME] + .units[0] + .run_action( + "rotate-key", + ) + ) + res = (await action.wait()).results + + new_kid = res["new-key-id"] + new_jwks = requests.get( + f"http://{public_address}/{ops_test.model.name}-{APP_NAME}/.well-known/jwks.json" + ) + + assert any(jwk["kid"] == new_kid for jwk in new_jwks.json()["keys"]) + assert len(new_jwks.json()["keys"]) == len(jwks.json()["keys"]) + 1 diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 67b4b1db..6db3c4e9 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -39,26 +39,112 @@ def mocked_sql_migration(mocker: MockerFixture) -> Generator: @pytest.fixture() -def mocked_create_client(mocker: MockerFixture) -> Generator: +def hydra_cli_client_json() -> Dict: + return { + "client_id": "07b318cf-9a9f-47b2-a288-972e671936a1", + "client_name": "", + "client_secret": "_hXRi23BeBc1kGhCQKRASz7nC6", + "client_secret_expires_at": 0, + "client_uri": "", + "created_at": "2023-03-17T13:03:53Z", + "grant_types": ["authorization_code"], + "jwks": {}, + "logo_uri": "", + "metadata": {}, + "owner": "", + "policy_uri": "", + "redirect_uris": ["https://example/oauth/callback"], + "registration_access_token": "ory_at_1hxSDuA1Ivyvi6Sy0iHfUFOVcASiOp4ZzVY4frtKMKo.7FliqVHMff94gacuKLKCnWEiCqMxJYs8jHmSw8iP03k", + "registration_client_uri": "http://localhost:4444/oauth2/register/07b318cf-9a9f-47b2-a288-972e671936a1", + "request_object_signing_alg": "RS256", + "response_types": ["code"], + "scope": "offline_access offline openid", + "subject_type": "public", + "token_endpoint_auth_method": "client_secret_basic", + "tos_uri": "", + "updated_at": "2023-03-17T13:03:53.389214Z", + "userinfo_signed_response_alg": "none", + } + + +@pytest.fixture() +def mocked_create_client(mocker: MockerFixture, hydra_cli_client_json: Dict) -> Generator: mock = mocker.patch("charm.HydraCLI.create_client") - mock.return_value = {"client_id": "client_id", "client_secret": "client_secret"} + mock.return_value = hydra_cli_client_json yield mock @pytest.fixture() -def mocked_update_client(mocker: MockerFixture) -> Generator: +def mocked_get_client(mocker: MockerFixture, hydra_cli_client_json: Dict) -> Generator: + mock = mocker.patch("charm.HydraCLI.get_client") + hydra_cli_client_json = dict(hydra_cli_client_json) + hydra_cli_client_json.pop("client_secret", None) + mock.return_value = hydra_cli_client_json + yield mock + + +@pytest.fixture() +def mocked_update_client(mocker: MockerFixture, hydra_cli_client_json: Dict) -> Generator: mock = mocker.patch("charm.HydraCLI.update_client") - mock.return_value = {"client_id": "client_id", "client_secret": "client_secret"} + hydra_cli_client_json = dict(hydra_cli_client_json) + hydra_cli_client_json.pop("client_secret", None) + hydra_cli_client_json.pop("registration_access_token", None) + hydra_cli_client_json.pop("registration_client_uri", None) + mock.return_value = hydra_cli_client_json yield mock @pytest.fixture() -def mocked_delete_client(mocker: MockerFixture) -> Generator: +def mocked_list_client(mocker: MockerFixture, hydra_cli_client_json: Dict) -> Generator: + mock = mocker.patch("charm.HydraCLI.list_clients") + hydra_cli_client_json = dict(hydra_cli_client_json) + hydra_cli_client_json.pop("client_secret", None) + hydra_cli_client_json.pop("registration_access_token", None) + hydra_cli_client_json.pop("registration_client_uri", None) + ret = {"items": [dict(hydra_cli_client_json, client_id=f"client-{i}") for i in range(20)]} + mock.return_value = ret + yield mock + + +@pytest.fixture() +def mocked_delete_client(mocker: MockerFixture, hydra_cli_client_json: Dict) -> Generator: mock = mocker.patch("charm.HydraCLI.delete_client") + mock.return_value = hydra_cli_client_json["client_id"] + yield mock + + +@pytest.fixture() +def mocked_revoke_tokens(mocker: MockerFixture) -> Generator: + mock = mocker.patch("charm.HydraCLI.delete_client_access_tokens") mock.return_value = "client_id" yield mock +@pytest.fixture() +def mocked_create_jwk(mocker: MockerFixture) -> Generator: + mock = mocker.patch("charm.HydraCLI.create_jwk") + mock.return_value = { + "set": "hydra.openid.id-token", + "keys": [ + { + "alg": "RS256", + "d": "a", + "dp": "b", + "dq": "c", + "e": "AQAB", + "kid": "183d04f5-9e7b-4d2e-91e6-5b91d17db16d", + "kty": "RSA", + "n": "d", + "p": "e", + "q": "f", + "qi": "g", + "use": "sig", + } + ], + } + yield mock + + @pytest.fixture() def mocked_set_provider_info(mocker: MockerFixture) -> Generator: yield mocker.patch("charm.OAuthProvider.set_provider_info_in_relation_data") diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index b2dad9d6..4c4fa2f5 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -573,7 +573,7 @@ def test_client_deleted_event_emitted( mocked_delete_client: MagicMock, mocked_hydra_is_running: MagicMock, ) -> None: - client_id = "client_id" + client_id = mocked_delete_client.return_value harness.set_can_connect(CONTAINER_NAME, True) harness.charm.on.hydra_pebble_ready.emit(CONTAINER_NAME) @@ -647,3 +647,203 @@ def test_exec_error_on_client_deleted_event_emitted( harness.charm.oauth.on.client_deleted.emit(relation_id) assert caplog.record_tuples[0][2] == f"Exited with code: {err.exit_code}. Stderr: {err.stderr}" + + +@pytest.mark.parametrize( + "action", + [ + "_on_create_oauth_client_action", + "_on_get_oauth_client_info_action", + "_on_update_oauth_client_action", + "_on_delete_oauth_client_action", + "_on_list_oauth_clients_action", + "_on_revoke_oauth_client_access_tokens_action", + "_on_rotate_key_action", + ], +) +def test_actions_when_cannot_connect(harness: Harness, action: str) -> None: + harness.set_can_connect(CONTAINER_NAME, False) + event = MagicMock() + + getattr(harness.charm, action)(event) + + event.fail.assert_called_with( + "Service is not ready. Please re-run the action when the charm is active" + ) + + +def test_create_oauth_client_action( + harness: Harness, mocked_hydra_is_running: MagicMock, mocked_create_client: MagicMock +) -> None: + harness.set_can_connect(CONTAINER_NAME, True) + event = MagicMock() + event.params = {} + + harness.charm._on_create_oauth_client_action(event) + + ret = mocked_create_client.return_value + event.set_results.assert_called_with( + { + "client-id": ret.get("client_id"), + "client-secret": ret.get("client_secret"), + "audience": ret.get("audience"), + "grant-types": ", ".join(ret.get("grant_types")), + "redirect-uris": ", ".join(ret.get("redirect_uris")), + "response-types": ", ".join(ret.get("response_types")), + "scope": ret.get("scope"), + "token-endpoint-auth-method": ret.get("token_endpoint_auth_method"), + } + ) + + +def test_get_oauth_client_info_action( + harness: Harness, mocked_hydra_is_running: MagicMock, mocked_get_client: MagicMock +) -> None: + harness.set_can_connect(CONTAINER_NAME, True) + ret = mocked_get_client.return_value + event = MagicMock() + event.params = { + "client-id": ret.get("client_id"), + } + + harness.charm._on_get_oauth_client_info_action(event) + + event.set_results.assert_called_with( + {k.replace("_", "-"): ", ".join(v) if isinstance(v, list) else v for k, v in ret.items()} + ) + + +def test_update_oauth_client_action( + harness: Harness, + mocked_hydra_is_running: MagicMock, + mocked_update_client: MagicMock, + mocked_get_client: MagicMock, +) -> None: + harness.set_can_connect(CONTAINER_NAME, True) + ret = mocked_update_client.return_value + event = MagicMock() + event.params = { + "client-id": ret.get("client_id"), + } + + harness.charm._on_update_oauth_client_action(event) + + event.set_results.assert_called_with( + { + "client-id": ret.get("client_id"), + "client-secret": ret.get("client_secret"), + "audience": ret.get("audience"), + "grant-types": ", ".join(ret.get("grant_types")), + "redirect-uris": ", ".join(ret.get("redirect_uris")), + "response-types": ", ".join(ret.get("response_types")), + "scope": ret.get("scope"), + "token-endpoint-auth-method": ret.get("token_endpoint_auth_method"), + } + ) + + +def test_update_oauth_client_action_when_oauth_relation_client( + harness: Harness, + mocked_hydra_is_running: MagicMock, + mocked_update_client: MagicMock, + mocked_get_client: MagicMock, +) -> None: + harness.set_can_connect(CONTAINER_NAME, True) + ret = mocked_update_client.return_value + event = MagicMock() + event.params = { + "client-id": ret.get("client_id"), + } + mocked_get_client.return_value["metadata"]["relation_id"] = 123 + + harness.charm._on_update_oauth_client_action(event) + + event.fail.assert_called_with( + f"Cannot update client `{ret.get('client_id')}`, " "it is managed from an oauth relation." + ) + + +def test_delete_oauth_client_action( + harness: Harness, + mocked_hydra_is_running: MagicMock, + mocked_delete_client: MagicMock, + mocked_get_client: MagicMock, +) -> None: + client_id = "client_id" + harness.set_can_connect(CONTAINER_NAME, True) + event = MagicMock() + event.params = { + "client-id": client_id, + } + + harness.charm._on_delete_oauth_client_action(event) + + event.set_results.assert_called_with({"client-id": client_id}) + + +def test_delete_oauth_client_action_when_oauth_relation_client( + harness: Harness, + mocked_hydra_is_running: MagicMock, + mocked_delete_client: MagicMock, + mocked_get_client: MagicMock, +) -> None: + harness.set_can_connect(CONTAINER_NAME, True) + client_id = mocked_delete_client.return_value + event = MagicMock() + event.params = { + "client-id": client_id, + } + mocked_get_client.return_value["metadata"]["relation_id"] = 123 + + harness.charm._on_delete_oauth_client_action(event) + + event.fail.assert_called_with( + f"Cannot delete client `{client_id}`, " + "it is managed from an oauth relation. " + "To delete it, remove the relation." + ) + + +def test_list_oauth_client_action( + harness: Harness, mocked_hydra_is_running: MagicMock, mocked_list_client: MagicMock +) -> None: + client_id = "client_id" + harness.set_can_connect(CONTAINER_NAME, True) + event = MagicMock() + event.params = { + "client-id": client_id, + } + + harness.charm._on_list_oauth_clients_action(event) + + ret = mocked_list_client.return_value + expected_output = {i["client_id"] for i in ret["items"]} + assert set(event.set_results.call_args_list[0][0][0].values()) == expected_output + + +def test_revoke_oauth_client_access_tokens_action( + harness: Harness, mocked_hydra_is_running: MagicMock, mocked_revoke_tokens: MagicMock +) -> None: + client_id = "client_id" + harness.set_can_connect(CONTAINER_NAME, True) + event = MagicMock() + event.params = { + "client-id": client_id, + } + + harness.charm._on_revoke_oauth_client_access_tokens_action(event) + + event.set_results.assert_called_with({"client-id": client_id}) + + +def test_rotate_key_action( + harness: Harness, mocked_hydra_is_running: MagicMock, mocked_create_jwk: MagicMock +) -> None: + harness.set_can_connect(CONTAINER_NAME, True) + ret = mocked_create_jwk.return_value + event = MagicMock() + event.params = {"alg": "SHA256"} + + harness.charm._on_rotate_key_action(event) + + event.set_results.assert_called_with({"new-key-id": ret["keys"][0]["kid"]}) diff --git a/unit-requirements.txt b/unit-requirements.txt index 177d9aa4..80c7d299 100644 --- a/unit-requirements.txt +++ b/unit-requirements.txt @@ -2,4 +2,5 @@ pytest pytest-mock coverage[toml] requests +ipdb -r requirements.txt