diff --git a/.github/dockerfiles/Dockerfile.keycloak b/.github/dockerfiles/Dockerfile.keycloak new file mode 100644 index 0000000..6356a7f --- /dev/null +++ b/.github/dockerfiles/Dockerfile.keycloak @@ -0,0 +1,19 @@ +FROM quay.io/keycloak/keycloak:latest as builder + +# Enable health and metrics support +ENV KC_HEALTH_ENABLED=true +ENV KC_METRICS_ENABLED=true + +# Configure a database vendor +ENV KC_DB=postgres + +WORKDIR /opt/keycloak +# for demonstration purposes only, please make sure to use proper certificates in production instead +RUN keytool -genkeypair -storepass password -storetype PKCS12 -keyalg RSA -keysize 2048 -dname "CN=server" -alias server -ext "SAN:c=DNS:localhost,IP:127.0.0.1" -keystore conf/server.keystore +RUN /opt/keycloak/bin/kc.sh build + +FROM quay.io/keycloak/keycloak:latest +COPY --from=builder /opt/keycloak/ /opt/keycloak/ + + +ENTRYPOINT ["/opt/keycloak/bin/kc.sh"] diff --git a/.github/dockerfiles/docker-compose.yml b/.github/dockerfiles/docker-compose.yml new file mode 100644 index 0000000..43bffbe --- /dev/null +++ b/.github/dockerfiles/docker-compose.yml @@ -0,0 +1,44 @@ +version: '3.8' + +services: + postgres: + image: postgres:latest + environment: + POSTGRES_DB: keycloak + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: keycloak + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - keycloak-network + + keycloak: + build: + context: . + dockerfile: Dockerfile.keycloak + environment: + KC_HOSTNAME: localhost + KC_HOSTNAME_PORT: 8080 + KC_HOSTNAME_STRICT_BACKCHANNEL: false + KC_HTTP_ENABLED: true + KC_HOSTNAME_STRICT_HTTPS: false + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://postgres/keycloak + KC_DB_USERNAME: keycloak + KC_DB_PASSWORD: keycloak + command: + - start-dev + ports: + - "8080:8080" + depends_on: + - postgres + networks: + - keycloak-network + +volumes: + postgres_data: + +networks: + keycloak-network: diff --git a/.github/scripts/setup_keycloak_client.py b/.github/scripts/setup_keycloak_client.py new file mode 100644 index 0000000..2890d99 --- /dev/null +++ b/.github/scripts/setup_keycloak_client.py @@ -0,0 +1,117 @@ +import json +import os +import time + +from keycloak import KeycloakAdmin + +# Environment variables +KEYCLOAK_SERVER_URL = os.environ.get("KEYCLOAK_SERVER_URL", "http://localhost:8080/") +KEYCLOAK_ADMIN_USERNAME = os.environ.get("KEYCLOAK_ADMIN_USERNAME", "admin") +KEYCLOAK_ADMIN_PASSWORD = os.environ.get("KEYCLOAK_ADMIN_PASSWORD", "admin") + +KEYCLOAK_REALM = os.environ.get("KEYCLOAK_REALM", "test") +KEYCLOAK_CLIENT_ID = os.environ.get("KEYCLOAK_CLIENT_ID", "test-client") +KEYCLOAK_CLIENT_SECRET = os.environ.get("KEYCLOAK_CLIENT_SECRET", "test-client-secret") + +# Initialize Keycloak Admin client +keycloak_admin = KeycloakAdmin( + server_url=KEYCLOAK_SERVER_URL, + username=KEYCLOAK_ADMIN_USERNAME, + password=KEYCLOAK_ADMIN_PASSWORD, + realm_name="master", + verify=False, +) + +# Wait for Keycloak server to be available +for _ in range(10): + try: + keycloak_admin.get_realms() + break + except: + time.sleep(5) +else: + raise Exception("Keycloak server is not responding.") + +# Check if the realm already exists +realms = keycloak_admin.get_realms() +if KEYCLOAK_REALM not in [realm["realm"] for realm in realms]: + # Create a new realm + keycloak_admin.create_realm(payload={"realm": KEYCLOAK_REALM, "enabled": True}) + print(f"Realm '{KEYCLOAK_REALM}' created.") + +# Switch to the new realm +keycloak_admin.connection.realm_name = KEYCLOAK_REALM + +# Check if the client exists +clients = keycloak_admin.get_clients() +client = next((c for c in clients if c["clientId"] == KEYCLOAK_CLIENT_ID), None) + +if not client: + # Create the client + client_id = keycloak_admin.create_client( + payload={ + "clientId": KEYCLOAK_CLIENT_ID, + "name": KEYCLOAK_CLIENT_ID, + "enabled": True, + "clientAuthenticatorType": "client-secret", + "secret": KEYCLOAK_CLIENT_SECRET, + "protocol": "openid-connect", + "publicClient": False, + "serviceAccountsEnabled": True, + "standardFlowEnabled": False, + "directAccessGrantsEnabled": False, + } + ) + print(f"Client '{KEYCLOAK_CLIENT_ID}' created.") +else: + client_id = client["id"] + print(f"Client '{KEYCLOAK_CLIENT_ID}' already exists.") + +# Get the service account user ID +service_account_user = keycloak_admin.get_client_service_account_user(client_id) +service_account_user_id = service_account_user["id"] + +# Get realm-management client ID +realm_management_client_id = keycloak_admin.get_client_id("realm-management") +realm_admin_role_id = keycloak_admin.get_client_role_id( + client_id=realm_management_client_id, role_name="realm-admin" +) + +realm_admin_role = keycloak_admin.get_role_by_id(role_id=realm_admin_role_id) + +# Check if the role is already assigned to the service account user +assigned_roles = keycloak_admin.get_client_role_members( + client_id=realm_management_client_id, + role_name="realm-admin", +) +role_already_assigned = any( + role["id"] == service_account_user_id for role in assigned_roles +) + +if not role_already_assigned: + # Assign the realm-admin role to the service account user + keycloak_admin.assign_client_role( + user_id=service_account_user_id, + client_id=realm_management_client_id, + roles=[realm_admin_role], + ) + print("Assigned 'realm-admin' role to the service account user.") +else: + print("'realm-admin' role is already assigned to the service account user.") + +# Test client connection +try: + keycloak_admin_test = KeycloakAdmin( + server_url=KEYCLOAK_SERVER_URL, + realm_name=KEYCLOAK_REALM, + client_id=KEYCLOAK_CLIENT_ID, + client_secret_key=KEYCLOAK_CLIENT_SECRET, + verify=False, + ) + print("Connected to the client using service account.") +except Exception as e: + raise Exception("Could not connect to the client.") from e + +# Get users info from the realm +users = keycloak_admin.get_users() +print(json.dumps(users, indent=2)) diff --git a/.github/scripts/setup_keycloak_mock.py b/.github/scripts/setup_keycloak_mock.py new file mode 100644 index 0000000..367c699 --- /dev/null +++ b/.github/scripts/setup_keycloak_mock.py @@ -0,0 +1,207 @@ +import os +from keycloak import KeycloakAdmin + +# Environment variables +KEYCLOAK_SERVER_URL = os.environ.get("KEYCLOAK_SERVER_URL", "http://localhost:8080/") +KEYCLOAK_ADMIN_USERNAME = os.environ.get("KEYCLOAK_ADMIN_USERNAME", "admin") +KEYCLOAK_ADMIN_PASSWORD = os.environ.get("KEYCLOAK_ADMIN_PASSWORD", "admin") + +KEYCLOAK_REALM = os.environ.get("KEYCLOAK_REALM", "test") +KEYCLOAK_CLIENT_ID = os.environ.get("KEYCLOAK_CLIENT_ID", "test-client") +KEYCLOAK_CLIENT_SECRET = os.environ.get("KEYCLOAK_CLIENT_SECRET", "test-client-secret") + +# Initialize Keycloak Admin client +keycloak_admin = KeycloakAdmin( + server_url=KEYCLOAK_SERVER_URL, + username=KEYCLOAK_ADMIN_USERNAME, + password=KEYCLOAK_ADMIN_PASSWORD, + realm_name=KEYCLOAK_REALM, + verify=False, +) + + +# Create users with additional attributes +def create_user(username: str, first_name: str, last_name: str, email: str): + keycloak_admin.create_user( + { + "username": username, + "firstName": first_name, + "lastName": last_name, + "email": email, + "enabled": True, + "credentials": [ + {"type": "password", "value": "password", "temporary": False} + ], + } + ) + + +USERS = [ + { + "username": "user1", + "first_name": "Alice", + "last_name": "Smith", + "email": "alice@example.com", + }, + { + "username": "user2", + "first_name": "Bob", + "last_name": "Brown", + "email": "bob@example.com", + }, + { + "username": "user3", + "first_name": "Carol", + "last_name": "Jones", + "email": "carol@example.com", + }, +] + +for user in USERS: + create_user(**user) + + +# Create groups +def create_group(name: str): + keycloak_admin.create_group({"name": name}) + + +GROUPS = ["developers", "testers", "admins"] +for group in GROUPS: + create_group(group) + + +# Add users to groups +def add_users_to_group(group_name: str, usernames: list): + group_id = keycloak_admin.get_group_by_path(f"/{group_name}")["id"] + for username in usernames: + user_id = keycloak_admin.get_user_id(username) + keycloak_admin.group_user_add(user_id=user_id, group_id=group_id) + + +add_users_to_group("developers", ["user1", "user2"]) +add_users_to_group("testers", ["user2", "user3"]) +add_users_to_group("admins", ["user3", "user1"]) + + +# Create realm roles +def create_realm_role(role_name: str): + keycloak_admin.create_realm_role({"name": role_name}) + + +REALM_ROLES = ["manage-users", "view-realm", "manage-clients"] +for role in REALM_ROLES: + create_realm_role(role) + + +# Assign realm roles to groups +def assign_realm_roles_to_group(group_name: str, role_names: list): + group_id = keycloak_admin.get_group_by_path(f"/{group_name}")["id"] + roles = [keycloak_admin.get_realm_role(role_name) for role_name in role_names] + keycloak_admin.assign_group_realm_roles(group_id=group_id, roles=roles) + + +assign_realm_roles_to_group("developers", ["view-realm"]) +assign_realm_roles_to_group("admins", ["manage-users", "manage-clients"]) + + +# Create client roles +def create_client_role(client_id: str, role_name: str): + client_uuid = keycloak_admin.get_client_id(client_id) + keycloak_admin.create_client_role(client_uuid, {"name": role_name}) + + +CLIENT_ROLES = { + "read-data": ["scope:read"], + "write-data": ["scope:write"], + "delete-data": ["scope:delete"], +} + +for role_name, scopes in CLIENT_ROLES.items(): + create_client_role(KEYCLOAK_CLIENT_ID, role_name) + + +# Assign client roles to groups +def assign_client_roles_to_group(group_name: str, client_id: str, role_names: list): + group_id = keycloak_admin.get_group_by_path(f"/{group_name}")["id"] + client_uuid = keycloak_admin.get_client_id(client_id) + roles = [ + keycloak_admin.get_client_role(client_uuid, role_name) + for role_name in role_names + ] + keycloak_admin.assign_group_client_roles( + group_id=group_id, client_id=client_uuid, roles=roles + ) + + +assign_client_roles_to_group( + "developers", KEYCLOAK_CLIENT_ID, ["read-data", "write-data"] +) +assign_client_roles_to_group("testers", KEYCLOAK_CLIENT_ID, ["read-data"]) +assign_client_roles_to_group( + "admins", KEYCLOAK_CLIENT_ID, ["read-data", "write-data", "delete-data"] +) + + +# Configure authentication flows +def create_auth_flow(flow_alias: str): + keycloak_admin.create_authentication_flow( + { + "alias": flow_alias, + "description": "Custom authentication flow for development", + "providerId": "basic-flow", + "topLevel": True, + "builtIn": False, + } + ) + + +create_auth_flow("dev-auth-flow") + +# Set the custom flow as the realm's browser flow +keycloak_admin.update_realm(KEYCLOAK_REALM, {"browserFlow": "dev-auth-flow"}) + + +# Add identity provider (e.g., GitHub) +def create_identity_provider(alias: str, provider_id: str, config: dict): + payload = { + "alias": alias, + "displayName": alias.capitalize(), + "providerId": provider_id, + "enabled": True, + "config": config, + } + keycloak_admin.create_identity_provider(payload) + + +create_identity_provider( + alias="github", + provider_id="github", + config={ + "clientId": "your-github-client-id", + "clientSecret": "your-github-client-secret", + "useJwksUrl": "true", + "jwksUrl": "https://github.com/login/oauth/access_token", + "authorizationUrl": "https://github.com/login/oauth/authorize", + "tokenUrl": "https://github.com/login/oauth/access_token", + "defaultScope": "user:email", + }, +) + +# Update realm-level settings (e.g., password policy) +keycloak_admin.update_realm( + KEYCLOAK_REALM, + { + "passwordPolicy": "length(8) and notUsername(#2) and regexPattern('^[a-zA-Z0-9]+$')", + "smtpServer": { + "host": "smtp.example.com", + "port": "587", + "from": "noreply@example.com", + "auth": "true", + "user": "smtp-user", + "password": "smtp-password", + }, + }, +) + +print("Development realm setup is complete.") diff --git a/.github/workflows/test_keycloak_integration.yaml b/.github/workflows/test_keycloak_integration.yaml new file mode 100644 index 0000000..2087099 --- /dev/null +++ b/.github/workflows/test_keycloak_integration.yaml @@ -0,0 +1,110 @@ +name: Test Keycloak Integration + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Build Keycloak Docker image + run: docker build -t keycloak-custom -f .github/dockerfiles/Dockerfile.keycloak . + + - name: Create Docker network + run: docker network create test-network + + - name: Start Postgres container + run: | + docker run -d \ + --name postgres \ + --network test-network \ + -e POSTGRES_DB=keycloak \ + -e POSTGRES_USER=keycloak \ + -e POSTGRES_PASSWORD=keycloak \ + postgres:latest + + - name: Wait for Postgres to be ready + run: | + echo "Waiting for Postgres..." + for i in {1..10}; do + if docker exec postgres pg_isready -U keycloak; then + echo "Postgres is ready." + break + else + echo "Postgres not ready yet..." + sleep 5 + fi + done + + - name: Start Keycloak container + run: | + docker run -d \ + --name keycloak \ + --network test-network \ + -p 8080:8080 \ + -e KC_HOSTNAME=localhost \ + -e KC_HOSTNAME_PORT=8080 \ + -e KC_HOSTNAME_STRICT_BACKCHANNEL=false \ + -e KC_HTTP_ENABLED=true \ + -e KC_HOSTNAME_STRICT_HTTPS=false \ + -e KEYCLOAK_ADMIN=admin \ + -e KEYCLOAK_ADMIN_PASSWORD=admin \ + -e KC_DB=postgres \ + -e KC_DB_URL=jdbc:postgresql://postgres/keycloak \ + -e KC_DB_USERNAME=keycloak \ + -e KC_DB_PASSWORD=keycloak \ + keycloak-custom \ + start-dev + + - name: Wait for Keycloak to be ready + run: | + echo "Waiting for Keycloak..." + for i in {1..10}; do + if curl -s http://localhost:8080/auth/realms/master > /dev/null; then + echo "Keycloak is up." + break + else + echo "Keycloak not ready yet..." + sleep 5 + fi + done + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.9" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest python-keycloak + + - name: Set up Keycloak realm and client + run: python .github/scripts/setup_keycloak_client.py + env: + KEYCLOAK_SERVER_URL: http://localhost:8080/ + KEYCLOAK_ADMIN_USERNAME: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KEYCLOAK_REALM: test + KEYCLOAK_CLIENT_ID: my-client + KEYCLOAK_CLIENT_SECRET: my-client-secret + + - name: Setup keycloak mock state + run: python .github/scripts/setup_keycloak_mock.py + env: + KEYCLOAK_SERVER_URL: http://localhost:8080/ + KEYCLOAK_ADMIN_USERNAME: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KEYCLOAK_REALM: test + KEYCLOAK_CLIENT_ID: my-client + KEYCLOAK_CLIENT_SECRET: my-client-secret + + - name: Run tests + run: pytest tests/keycloak/ diff --git a/tests/keycloak/test_users.py b/tests/keycloak/test_users.py new file mode 100644 index 0000000..a34ee36 --- /dev/null +++ b/tests/keycloak/test_users.py @@ -0,0 +1,6 @@ +import requests + + +def test_server_response(): + response = requests.get("http://localhost:8080/") + assert response.status_code == 200