Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Client credential authentication for configuration download #65

Merged
merged 6 commits into from
Dec 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,22 @@ You need a realm export as input to the latter commands.
It can be acquired using the Keycloak administration interface or using the `download` command:

```shell
kcwarden download --realm $REALM --user $USER --output $KEYCLOAK_CONFIG_FILE $KEYCLOAK_BASE_URL
kcwarden download --realm $REALM --auth-method password --user $USER --output $KEYCLOAK_CONFIG_FILE $KEYCLOAK_BASE_URL
```

Additionally, you might specify a separate realm for login, e.g., the `master` realm, using the `--auth-realm` parameter.
The password will be promoted interactively, or loaded from the environment variable `$KCWARDEN_KEYCLOAK_PASSWORD` if set.



If you want to run `kcwarden` as part of a pipeline, we recommend using service account authentication instead. Create a confidential client with a service account, and assign the `manage-realm`, `manage-clients` and `manage-users` roles for the relevant realm to it. Then, use kcwarden like this:

```bash
kcwarden download --auth-method client --client-id kcwarden-client --client-secret $YOUR_CLIENT_SECRET
# (add additional parameters as needed)
```

You can also omit the `--client-secret` parameter, in which case it will be loaded from the `$KCWARDEN_CLIENT_SECRET` environment variable.

## Running the Audit

Expand Down
31 changes: 26 additions & 5 deletions kcwarden/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,7 @@ def add_download_parser(subparsers):
parser_download = subparsers.add_parser(
"download",
aliases=["d"],
help="Download the Keycloak realm configuration.\n\n"
"The password will be requested interactively or read from the KEYCLOAK_PASSWORD env variable.",
help="Download the Keycloak realm configuration.",
)
parser_download.set_defaults(func=download.download_config)
parser_download.add_argument(
Expand All @@ -133,18 +132,40 @@ def add_download_parser(subparsers):
default="master",
required=False,
)

# Authentication method choice
parser_download.add_argument(
"-m",
"--auth-method",
choices=["password", "client"],
required=True,
help="Choose the authentication method: password or client (client-credential grant / service account)",
)

# User authentication options
parser_download.add_argument(
"-u",
"--user",
help="The user used for authentication",
required=True,
help="The username used for user authentication. The password will be interactively prompted or read from the KCWARDEN_KEYCLOAK_PASSWORD env variable.",
)
parser_download.add_argument(
"-t",
"--totp",
help="Indicates that a TOTP code is required for authentication",
help="Indicates that a TOTP code is required for authentication. The value will be interactively prompted",
action="store_true",
)

# Client credentials (can be used for both user and service account auth)
parser_download.add_argument(
"--client-id",
default="admin-cli",
help="The client ID for authentication. Defaults to admin-cli for password authentication.",
)
parser_download.add_argument(
"--client-secret",
help="The client secret for authentication, if required. Optional for password authentication, required for service account authentication. Can be omitted, in which case it will be read from the KCWARDEN_CLIENT_SECRET env variable.",
)

parser_download.add_argument(
"-o",
"--output",
Expand Down
64 changes: 50 additions & 14 deletions kcwarden/subcommands/download.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import contextlib

import requests
from requests.auth import HTTPBasicAuth
import argparse
from getpass import getpass
import os
Expand All @@ -28,34 +27,58 @@ def authorized_get(url, token):

### Authentication-related functions
def get_password(user):
if "KEYCLOAK_PASSWORD" in os.environ:
return os.environ["KEYCLOAK_PASSWORD"]
if "KCWARDEN_KEYCLOAK_PASSWORD" in os.environ:
return os.environ["KCWARDEN_KEYCLOAK_PASSWORD"]
return getpass("Please enter the password for user {}: ".format(user))


def get_client_secret():
return os.environ.get("KCWARDEN_CLIENT_SECRET", "")


def get_totp():
return input("Please enter the TOTP code: ")


def get_session(base_url, user, totp_required, auth_realm):
def get_token_password_grant(base_url, auth_realm, user, totp_required, client_id="admin-cli", client_secret="pass"):
password = get_password(user)

auth_data = {"username": user, "password": password, "grant_type": "password"}
auth_data = {
"username": user,
"password": password,
"grant_type": "password",
"client_id": client_id,
"client_secret": client_secret,
}

if totp_required:
auth_data["totp"] = get_totp()

token_url = KC_TOKEN_AUTH.format(base_url, auth_realm)

req = requests.post(token_url, auth=HTTPBasicAuth("admin-cli", "pass"), data=auth_data)
req = requests.post(token_url, data=auth_data)
try:
req.json()
json_response = req.json()
except requests.RequestException:
assert False, "Could not parse JSON. Response was: {}".format(req.content)
assert "access_token" in req.json(), "Did not receive an access token in response. Response was: {}".format(
req.json()
raise ValueError(f"Could not parse JSON. Response was: {req.content}")
if "access_token" not in json_response:
raise ValueError(f"Did not receive an access token in response. Response was: {json_response}")
return json_response["access_token"]


def get_token_client_credential_grant(base_url, auth_realm, client_id, client_secret):
token_url = KC_TOKEN_AUTH.format(base_url, auth_realm)

req = requests.post(
token_url, data={"grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret}
)
return req.json()["access_token"]
try:
json_response = req.json()
except requests.RequestException:
raise ValueError(f"Could not parse JSON. Response was: {req.content}")
if "access_token" not in json_response:
raise ValueError(f"Did not receive an access token in response. Response was: {json_response}")
return json_response["access_token"]


### Main Loop
Expand All @@ -65,9 +88,22 @@ def download_config(args: argparse.Namespace):

realm = args.realm
output_file = args.output

session_token = get_session(base_url, args.user, args.totp, args.auth_realm)

client_secret = args.client_secret

if client_secret is None:
client_secret = get_client_secret()

if args.auth_method == "password":
session_token = get_token_password_grant(
base_url, args.auth_realm, args.user, args.totp, args.client_id, client_secret
)
elif args.auth_method == "client":
session_token = get_token_client_credential_grant(base_url, args.auth_realm, args.client_id, client_secret)
else:
print("Unexpected auth_method provided - please file a bug report, this should be impossible")
return 1

print(session_token)
export = requests.post(
KC_EXPORT_URL.format(base_url, realm), headers={"Authorization": f"Bearer {session_token}"}
).json()
Expand Down
4 changes: 3 additions & 1 deletion tests/integration/subcommands/test_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ def test_download_config(keycloak: KeycloakAdmin, tmp_path: Path):
output_path = tmp_path / "config.json"
test_args = [
"download",
"--auth-method",
"password",
keycloak.connection.server_url,
"--user",
keycloak.connection.username,
Expand All @@ -21,7 +23,7 @@ def test_download_config(keycloak: KeycloakAdmin, tmp_path: Path):
"--realm",
"master",
]
os.environ["KEYCLOAK_PASSWORD"] = keycloak.connection.password
os.environ["KCWARDEN_KEYCLOAK_PASSWORD"] = keycloak.connection.password
cli.main(test_args)

with output_path.open() as f:
Expand Down
Loading